6.11. Проектирование сервисов и методов
Проектирование сервисов и методов
1. Определение границ сервиса
Начнём с вопроса: что вообще считать сервисом?
В современной практике термин «сервис» используется в нескольких значениях:
- Микросервис — автономное приложение со своей БД, жизненным циклом и API.
- Domain-сервис — класс в доменном слое, реализующий логику, не принадлежащую конкретной сущности (например,
TransferServiceдля перевода денег между счетами). - Application-сервис — точка входа в приложение (например,
CreateOrderCommandHandler), координирующий транзакции и вызовы домена. - Интеграционный сервис — адаптер к внешней системе (платёжный шлюз, SMS-провайдер).
Независимо от уровня, проектирование любого сервиса начинается с ответа на три вопроса:
- Какова его единственная цель?
Если не удаётся сформулировать её одним предложением — границы размыты.
Пример хорошей формулировки: «Обеспечивает создание заказа с проверкой доступности товара, резервированием и инициацией оплаты».
Пример плохой: «Управляет заказами» — слишком широко.
- Какие акторы инициируют его работу?
Это определяет контракт и уровень детализации. Сервис, вызываемый пользователем через UI, должен валидировать входные данные и формировать понятные сообщения об ошибках. Сервис, вызываемый другим внутренним компонентом в рамках транзакции, может полагаться на предварительную валидацию и возвращать структурированные исключения.
- Какие внешние зависимости он имеет?
Каждая зависимость — это потенциальная точка отказа и сложность интеграционного тестирования. Если сервис зависит от пяти внешних систем, вероятно, его стоит разделить или ввести уровень абстракции (например, шлюз агрегации).
Практический алгоритм определения границ:
- Выделите бизнес-транзакцию — атомарную операцию, имеющую смысл для предметной области (например, «оформить заказ», «подтвердить email»).
- Определите участников — какие сущности, агрегаты, внешние системы участвуют.
- Проверьте, нарушает ли операция инварианты нескольких агрегатов.
- Если инвариант одного агрегата — логика принадлежит самому агрегату (
Order.AddLine()). - Если инвариант нескольких — требуется domain-сервис (
OrderFulfillmentService).
- Если инвариант одного агрегата — логика принадлежит самому агрегату (
- Оцените жизненный цикл — должен ли сервис обрабатывать асинхронные события, компенсационные действия, повторные попытки? Это влияет на выбор паттернов (Saga, Circuit Breaker).
Важно: границы сервиса должны совпадать с границами согласованности данных. Если для выполнения операции требуются данные из нескольких БД без распределённой транзакции — это сигнал к пересмотру: либо агрегировать данные заранее (через событийную модель), либо выделить отдельный процесс оркестрации.
2. Проектирование контракта метода
Контракт метода — это его «публичное обещание»: что он принимает, что гарантирует и что может пойти не так. Хороший контракт позволяет клиенту использовать метод без изучения его исходного кода.
Контракт включает четыре аспекта:
2.1. Сигнатура (имя, параметры, возвращаемое значение)
- Имя должно отражать действие, а не реализацию:
ReserveInventory()лучше, чемCallWarehouseApi(). - Параметры — минимально необходимый набор. Избегайте «мешков» вроде
Dictionary<string, object>или «god-объектов» с 15 полями. Если параметров больше трёх, стоит рассмотреть введение DTO (Data Transfer Object). - Возвращаемое значение должно быть однозначным:
void— если метод идемпотентен и не требует подтверждения (например,LogEvent()),Result<T, Error>илиEither— в функциональных стилях, где ошибки часть контракта,Task<T>— если асинхронно,- Исключения — только для исключительных ситуаций (например, сбой инфраструктуры), а не для бизнес-ошибок («недостаточно средств» — не исключение).
2.2. Предусловия
Что должно быть истинно до вызова метода?
- Валидность входных данных (например,
emailсоответствует RFC 5322), - Состояние системы (например, «пользователь аутентифицирован»),
- Ограничения окружения (например, «доступна сеть»).
Предусловия не проверяются внутри метода, если они относятся к ответственности вызывающего кода. Например, метод ProcessPayment(PaymentRequest request) не проверяет, авторизован ли пользователь — этим должен заняться слой авторизации выше.
2.3. Постусловия
Что гарантированно будет истинно после успешного выполнения?
- Изменения в состоянии («заказ создан в статусе Draft»),
- Побочные эффекты («отправлено email-уведомление»),
- Инварианты («баланс неотрицателен»).
Постусловия — основа для тестирования: unit-тесты проверяют, что постусловия выполняются при заданных предусловиях.
2.4. Обработка ошибок
Критически важный элемент. Ошибки делятся на три категории:
| Категория | Пример | Как обрабатывать |
|---|---|---|
| Бизнес-ошибки | «Недостаточно товара на складе», «Карта просрочена» | Возвращать структурированный результат (Result.Fail(InsufficientStock)), не бросать исключения. Клиент может принять решение (предложить альтернативу, запросить подтверждение). |
| Ошибки валидации | «Email пуст», «Сумма ≤ 0» | Возвращать список нарушений (ValidationResult). Часто обрабатываются на уровне представления. |
| Системные ошибки | «Таймаут БД», «Сбой сети» | Исключения или Result.Fail<SystemFailure>. Обрабатываются на уровне инфраструктуры (повтор, fallback, логирование). |
Исключение — это сигнал о непредвиденном состоянии. Если ошибка прогнозируема (даже если редка), она должна быть частью контракта.
3. Идемпотентность и безопасность
Эти свойства определяют, как метод реагирует на повторный вызов — ключевой фактор при проектировании распределённых систем.
- Безопасный (safe) метод — не изменяет состояние системы. В HTTP это
GET,HEAD,OPTIONS. - Идемпотентный метод — повторный вызов с теми же параметрами не изменяет результат после первого успешного выполнения.
PUT,DELETEв REST — идемпотентны;POST— обычно нет.
Почему это важно?
- Сетевые сбои делают повторные вызовы неизбежными.
- Клиент не может отличить «метод не дошёл» от «метод выполнился, но ответ потерялся».
- Без идемпотентности повтор приведёт к дубликатам (два заказа, два списания).
Как обеспечить идемпотентность?
- Ввести идемпотент-ключ (например,
X-Idempotency-Key: <guid>), генерируемый клиентом. - Сохранять результат первого вызова с этим ключом.
- При повторном вызове с тем же ключом — возвращать кэшированный результат.
Это требует дополнительного хранилища (обычно TTL-кэш на 24–72 часа), но исключает бизнес-риски.
4. Версионирование и эволюция
Система живёт дольше, чем любой отдельный метод. Проектирование должно учитывать будущие изменения.
Стратегии версионирования:
| Подход | Как | Плюсы | Минусы |
|---|---|---|---|
Версия в URL (/api/v1/orders) | Явная, простая | Чёткое разделение | Нарушает принцип, что URL — идентификатор ресурса |
Версия в заголовке (Accept: application/vnd.myapp.v2+json) | Семантически корректно (RFC 7240) | Сохраняет чистоту URL | Сложнее тестировать, требует инфраструктурной поддержки |
Параметр запроса (?version=2) | Гибко | Поддержка нескольких версий в одном endpoint’е | Риск кэширования старой версии прокси |
Правила эволюции контракта (для обратной совместимости):
- Добавлять поля можно — клиенты проигнорируют новые.
- Удалять поля нельзя — сломает старые клиенты. Вместо этого помечать как
deprecated. - Изменять тип поля нельзя. Если нужен новый формат — добавлять новое поле (
price_v2). - Изменять семантику — только с новой версией API.
Внутри системы (не в публичном API) можно использовать более гибкие механизмы: feature flags, стратегии миграции («двумя столбцами»), события с версией схемы (OrderCreated_v2).
5. Тестирование как часть проектирования
Метод, который нельзя протестировать изолированно, плохо спроектирован. Критерий: можно ли написать unit-тест без запуска БД, сети, UI?
Техники обеспечения тестируемости:
- Инверсия зависимостей: передавать репозитории, сервисы через интерфейсы.
- Чистые функции: выносить вычисления без побочных эффектов (расчёт налогов, валидация).
- Изоляция времени: вместо
DateTime.Now—IClock.Now, чтобы управлять временем в тестах. - Фиксированные входные данные: избегать
Guid.NewGuid(), использоватьIGuidGenerator.
Unit-тест должен проверять один сценарий поведения. Если для покрытия метода требуется 20 тестов — он слишком велик (нарушение SRP).